Skip to content

contextdb v2: a search index for vector columns - #22

Merged
lucidprogrammer merged 2 commits into
devfrom
v2-vector-search-index
Nov 12, 2026
Merged

lucidprogrammer merged 2 commits into
devfrom
v2-vector-search-index

Conversation

@lucidprogrammer

Copy link
Copy Markdown
Contributor

Adds a search index for vector columns to the workspace under v2/. Nothing that builds from crates/ changes; the root Cargo.toml and Cargo.lock are untouched. One commit on dev c79bdee.

What is in it

  • An index a column grows into. A vector column is searched exactly until it holds AUTO_INDEX_AT vectors (100,000 unless declared) or declares SEARCH_MODE INDEXED. Until then no centre and no sketch is written, so a small store stays the size it was. From there each partition's sealed vectors are grouped around centres, and every vector gets a one-bit-per-number sketch that a search reads before the vectors themselves.
  • How a search uses it. It ranks the centres, takes the groups whose centre is within a measured distance of the best one, estimates their sketches and those of the rows sealed since the build, passes what the sketches keep through the one-byte screen of the previous change, and decides by true distance. Every row of an answer carries its true distance. Three modes: USE VECTOR EXACT, USE VECTOR INDEXED, and AUTO, which is what a statement gets when it names none.
  • What an indexed answer promises. How far the scan reaches and how far a sketch is trusted are measured when the index is built, on questions made from the stored vectors: half are stored vectors, half lie between two. A row counts as kept only when its sketch covers the row's own true similarity, both as a grouped row and as a row written after the build, and at least 99 in 100 of those questions' ten nearest rows must be kept. This is a measure, not a promise for any one question. USE VECTOR EXACT is exact.
  • Filters. A filter on rows is asked of each row the search visits, and the search widens until it holds as many allowed rows as were asked for. When fewer rows pass than LIMIT, AUTO finishes as an exact search and INDEXED is refused by name.
  • Building is a call the host makes. run_maintenance_cycle seals new vectors as before and now builds a partition's index in bounded steps, switching it in with one commit. A kill at any storage call leaves every vector found. Rows written meanwhile are searched through their own sketches until the next build takes them in. A step that does not fit the memory limit is recorded as stalled and the index in force keeps serving.
  • Reduced vectors. quantization = 'SQ8' stores a table's vectors at one byte a number; it is an option a table chooses, never a default. SQ4 is refused by name.
  • Inspection. SHOW VECTOR_INDEXES, SHOW VECTOR_PARTITIONS, Database::explain and the shell's .explain say what a column holds and what a search would do.
  • Damage. A grouping or sketch record that disagrees with itself is reported as damage, never a panic; under AUTO the search answers exactly in its place.
  • The check. .github/workflows/check.yml states 15,500 shipped lines and 260 tests (it stated 11,000 and 180).

Measured at this commit

By v2/numbers.sh: a release build on a 16-core AMD Ryzen 9 9955HX with 64 GB of memory and an NVMe drive, with nothing else running. One query at a time on one thread, a read session at 1 GiB, 95th percentile of 100 questions. Recall is the share of the exact answer's rows that the indexed answer returned; "all ten" counts the questions whose ten rows were all the same. The library picks the processor's bit-count instruction when it runs, and these times are with it.

Ten nearest Exact Indexed Recall, all ten
100,008 real text passages of 768 numbers in 8 partitions: one partition 1.41 ms 0.68 ms 1.000, 100 of 100
the same, several partitions 3.84 ms 1.54 ms 0.999, 99
the same, all partitions 10.88 ms 4.05 ms 0.999, 99
the same, with a tenth of the passages written after the build 10.78 ms 3.75 ms 1.000, 100
the same, with a further condition that passes one row in a hundred 38.45 ms 32.41 ms 1.000, 100
the same, all partitions, under a 64 MiB limit 11.38 ms 0.999, 99
10,000 vectors in one partition, the column declared AUTO_INDEX_AT 1000 1.19 ms 0.54 ms 0.996, 96
one million generated vectors in clusters 180.9 ms 1.11 ms 1.000, 100
one million generated camera sightings 175.4 ms 30.35 ms (0.61 ms at the middle question) 1.000, 100
11,588 real passages (under the size, so nothing is grouped) 1.44 ms 1.44 ms, the same exact search 1.000, 100
Previous change This change
100,008 passages on disk 417.8 MiB sealed 471.8 MiB right after the index is built. The index itself is 12.0 MiB (125 bytes a vector, 2.9%); the rest is pages the build freed, which the file keeps in this version
First build of a program that embeds the library 6 s, 47 packages, 3.0 MiB 8 s, 47 packages, 3.2 MiB
Fresh clone to a green cargo test 2.7 min 3.2 min
Shipped lines; tests 10,809; 171 15,443; 253 (234 tests and 19 SQL script files)

Against the current engine, like for like

The same rows loaded clean into both engines, 500 to a commit, into the same table: text passages with one 768-number embedding each, in 8 partitions (a real workload that is not in the repository). The same 100 questions, a read session at 1 GiB, one query at a time, one core each, 95th percentile, same machine. The current engine and this change's exact search return the same ten rows for every question; this change's indexed search returns them at the recall above.

100,008 passages: find in one / several / all partitions
Current engine 522 / 1,454 / 4,031 ms
This change, exact 1.41 / 3.84 / 10.88 ms
This change, indexed 0.68 / 1.54 / 4.05 ms

Verification

  • Every command of check.yml as written, on the commit, plus the build on Rust 1.89 and a cross-compiled lint for aarch64-apple-darwin: all pass. 234 of 234 tests and the documentation tests; the storage library's 202; 15,443 shipped lines against the stated 15,500 and 253 tests against the stated 260.
  • GitHub Actions on 718698cda: the run is linked in a comment below when it finishes.
  • Read by two other model families before this commit, and their findings fixed: a block sealed while a partition's first build starts was left without sketches; damaged stored bytes that could panic or pass unreported in four places; a visited row counted twice, which sent most searches around a second time; a filtered search that could lose an allowed row; an accuracy measure that was too lenient; a build step that returned the memory refusal where it should record a stall; a wrong line in explain; memory taken before it was asked for. The changes made after the second read were not read again by another model family.
  • Two of those fixes have no test that separates them (the row counted once, and the threshold that rows written after a build are held against). The passes_mean, sketches_estimated_mean and tail lines of v2/numbers.sh are what shows them.

Known limits of this version

  • Nothing seals or builds on its own; a host calls run_maintenance_cycle. Before a build a search returns the exact answer, slower.
  • Right after an index is built the file is larger than the index adds: 1.13 times the sealed store at 100,008 passages and 1.31 times at a million vectors. The freed pages are given back by a later change.
  • The slowest questions cost far more than the middle one where a question lies near no group: 30 ms against 0.6 ms on a million generated sightings.
  • The accuracy measure is taken at build time, on the build's own questions, at ten rows. A question unlike both kinds, rows written after a build that are unlike the rows the build saw, and a LIMIT above ten are not what it measured.
  • The reference that sketches of rows written after a build are taken from (a partition's mean) is set at the partition's first build and kept.
  • On small or low-dimension stores an indexed search can make more distance evaluations than an exact one; it still reads fewer bytes.
  • A condition that passes few rows is searched through the vector index. Reading the condition's own index first comes with the query language.
  • EXPLAIN SELECT is not accepted as SQL; .explain and Database::explain are the doors. A VECTOR(n) column's clauses must be written in one order.
  • A reduced (SQ8) vector kept inside its row is expanded when the row is read, before the read's memory is asked for.

@lucidprogrammer

Copy link
Copy Markdown
Contributor Author

GitHub Actions on 0f254add6: https://github.com/context-graph-ai/contextdb/actions/runs/43081843056 is green, 4 jobs (lint, test (macos-latest), test (ubuntu-latest), minimum-rust), 18:52 to 18:58 UTC.

The first run, on 718698cda (https://github.com/context-graph-ai/contextdb/actions/runs/43079326638), failed once on macOS in one test and passed when run again. The cause was in a test helper that dates from the first change: the parent sent the kill and then closed the child process input before the child was gone, so on macOS the child could read end-of-input and leave by itself. The second commit keeps that input open until the child has been killed and waited for. The engine is unchanged by it.

@lucidprogrammer
lucidprogrammer merged commit 4d1bf48 into dev Nov 12, 2026
4 checks passed
@lucidprogrammer
lucidprogrammer deleted the v2-vector-search-index branch November 12, 2026 00:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant